Skip to content

Fix cargo resolver pruning of optional/feature-gated dependencies#191

Open
evandowning wants to merge 2 commits into
masterfrom
worktree-fix-issue-189-cargo-optional-deps
Open

Fix cargo resolver pruning of optional/feature-gated dependencies#191
evandowning wants to merge 2 commits into
masterfrom
worktree-fix-issue-189-cargo-optional-deps

Conversation

@evandowning

Copy link
Copy Markdown
Collaborator

Fixes #189.

Problem

The cargo resolver marked every declared dependency of a package as already-resolved. But cargo metadata does not add optional/feature-gated dependencies to its resolved package set when the active feature set doesn't enable them. Those deps therefore matched no package in the cache, and resolution.py pruned them and their entire subtrees.

Net effect: for cargo projects, it-depends' core "resolve all possible dependency versions" behavior collapsed to "the single locked dependency tree" (e.g. rayon, backtrace, afl and ~30 other crates reachable only via optional edges were dropped for the siderophile fixture).

Changes

  1. resolve_from_source — collect all packages first, then only set_resolved(dep) when a matching package was actually added to the cache. Inactive optional deps fall through to normal resolution instead of being marked resolved-to-nothing and pruned. This also unifies the previously-separate handling of the SourcePackage's direct deps and transitive deps.

  2. get_dependencies — wrap the cargo metadata subprocess call in except subprocess.CalledProcessError, mirroring pip.py's johnnydep handling (Add exception handling #125). Previously a single crate whose temp-project cargo metadata failed (exit 101) raised CalledProcessError and aborted the entire resolution; the old pruning shortcut had inadvertently masked this by never resolving those crates.

Tests

Added test_resolve_from_source_only_marks_resolved_deps, which mocks get_dependencies (no cargo binary required) and verifies that a dep with a matching resolved package is marked resolved while optional deps with no matching package are left unresolved.

Open question (draft)

Fix (1) restores full "all possible versions" expansion, which is large — the issue reports ~963 crates for siderophile vs. 163 in the 2021 baseline and 129 on current master. Per the issue, whether the intended cargo semantics are the full possible set, the locked tree, or a middle ground (e.g. union of all features at locked versions) should be decided deliberately. Opening as a draft so that semantics decision can be settled before merge.

Note: cargo is not installed in this environment, so the end-to-end siderophile reproduction and the re-enabled smoke test (#181) were not run here — CI should exercise those.

🤖 Generated with Claude Code

The cargo resolver marked every declared dependency of a package as
already-resolved, including optional/feature-gated deps that `cargo
metadata` does not activate under the default feature set. Those deps
resolved to nothing and were pruned, collapsing it-depends' "resolve all
possible versions" behavior to the single locked tree for cargo projects.

Only mark a dependency resolved when `cargo metadata` actually added a
matching package to the cache, so inactive optional deps fall through to
normal resolution instead of being pruned.

Also wrap the `cargo metadata` subprocess call in `get_dependencies` with
CalledProcessError handling, mirroring pip.py (#125), so one unresolvable
crate no longer aborts the entire resolution.

Fixes #189

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@evandowning evandowning self-assigned this Jul 1, 2026
@evandowning evandowning added the integration Runs integration tests label Jul 1, 2026
@evandowning evandowning requested a review from ESultanik July 1, 2026 12:44
@evandowning

Copy link
Copy Markdown
Collaborator Author

Ran the siderophile reproduction + re-enabled smoke test

The PR note said cargo wasn't installed here so the end-to-end siderophile reproduction and the re-enabled smoke test (#181) weren't run. I ran them with the rust toolchain (cargo 1.96.1) installed, applying this PR plus #181's re-enabled smoke test on top of master.

Reproduction — confirms the fix works

pytest test/test_smoke.py::TestSmoke::test_cargo --runintegration (10m48s, resolves trailofbits/siderophile@7bca0f5):

Smoke test — fails on one invariant, but it's baseline drift, not a regression

The only failure is:

AssertionError: resolution missing expected package(s) ['cargo:cc']

Root cause, verified from the resolved graph:

  • In the 163-package baseline, cc was reachable only via afl@0.10.1 → cc.
  • The full expansion now selects newer afl (0.15.24, 0.16.0), which dropped the cc build-dependency (afl now deps libc, rustc_version, xdg).
  • The remaining paths to cc (flate2 → cloudflare-zlib-sys, bitstream-io → core2) hit the fully-yanked crates above, so those subtrees legitimately error out.

So cc genuinely isn't in the current resolution. The baseline siderophile.expected.json predates both this fix and the current crates.io state — this is exactly the drift the "open question" here and #181 anticipate. The gap needs the semantics decision plus a re-baseline via test/rebuild_expected_output.py, not a code change. I did not rebuild the baseline, since that's the decision this draft defers.

🤖 Generated with Claude Code

@evandowning evandowning marked this pull request as ready for review July 1, 2026 12:54
@ESultanik

Copy link
Copy Markdown
Collaborator

I am a bit conflicted on the optional dependency semantics decision. My current instinct is to ignore optional dependencies that aren't explicitly required. It's entirely possible that a package could have an incompatible subset of optional dependencies; so if we force them all to be expanded, there might be no valid resolution! In other words, there might be a mutually exclusive set of optional dependencies. @evandowning What do you think? Our decision on this will likely affect the order in which we merge this PR versus #181.

@evandowning

Copy link
Copy Markdown
Collaborator Author

@ESultanik That's a great point. I'll change this to ignore optional dependencies.

Per PR review: rather than expanding every optional/feature-gated
dependency into the graph, drop optional deps that the active feature
set does not require. A crate may declare a mutually exclusive set of
optional deps, so forcing them all in can describe a configuration with
no valid resolution.

Filter optional deps in `get_dependencies` so they never become edges.
The `resolve_from_source` match guard now only protects required deps
that cargo failed to resolve (e.g. a subcrate whose `cargo metadata`
errored), leaving them for normal resolution instead of pruning.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@evandowning

Copy link
Copy Markdown
Collaborator Author

Pushed a commit implementing the "ignore optional dependencies that aren't explicitly required" semantics from the review.

What changed

get_dependencies now skips any dependency with "optional": true in cargo metadata, alongside the existing dev/build (kind) filter. Optional deps never become edges in the graph, so they are neither expanded nor pruned. Only deps cargo requires under the active feature set are tracked.

This directly addresses the mutual-exclusivity concern: a crate can declare a mutually exclusive set of optional deps (e.g. sqlx's runtime-tokio-rustls vs runtime-async-std-native-tls backends, guarded by a compile-time check), and forcing them all into one resolution would describe a configuration that can never build. Ignoring inactive optional deps avoids ever constructing such a set.

The resolve_from_source match guard is kept but its purpose narrows: with optional deps filtered upstream, it now only protects a required dep that cargo failed to resolve (e.g. a subcrate whose cargo metadata errored) — leaving it for normal resolution rather than marking it resolved-to-nothing and pruning it. The CalledProcessError handling from the first commit is unchanged and still needed.

Tests

  • Added test_get_dependencies_ignores_optional_deps: mocks cargo metadata output and asserts an optional normal dep (rayon) and a dev dep (criterion) are dropped while a required dep (serde) survives.
  • Reframed test_resolve_from_source_only_marks_resolved_deps to reflect the narrowed guard (required-but-unresolved deps, not optional deps).

test/test_cargo.py: 8 passed. ruff + mypy clean.

Tradeoff worth flagging

This is the conservative end of the semantics spectrum. Because cargo marks a dep optional: true even when a default feature enables it, an optional dep reachable only via optional edges is now dropped — including the ~34 crates (rayon, backtrace, afl, …) that #189 noted were missing. So the resolved set returns to roughly the locked-tree size rather than the ~971-package full expansion. That is the intended effect of this semantics choice, but it does mean the re-baselined siderophile.expected.json (and the #181 smoke test) should be regenerated against this behavior, not the full-expansion behavior.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

integration Runs integration tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cargo resolver prunes optional/feature-gated dependencies (collapses "all possible versions" to the locked tree)

2 participants